Skip to content

Give code blocks a header with a language and copy button - #3317

Open
enf0rc3 wants to merge 10 commits into
mainfrom
wl/nes-285-code-block
Open

Give code blocks a header with a language and copy button#3317
enf0rc3 wants to merge 10 commits into
mainfrom
wl/nes-285-code-block

Conversation

@enf0rc3

@enf0rc3 enf0rc3 commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

NES-285 — the CodeBlock component from the Documentation vision file.

Every fenced code block gets a header carrying an optional label, its language, and a copy button.

What changed

The shell is static HTML. src/plugins/shiki-code-block.js is a Shiki transformer that emits the frame, header, label, language and copy button at build time, for all 1,599 fences across .md and .mdx. With scripting off, a block still renders complete.

Labels come from the fence meta. ```powershell Rename a deployment target becomes the block's label. Shiki drops that string, so the transformer keeps it. Most blocks have no label yet and render with the language and copy button alone — labelling the rest is content work for follow-up PRs.

Several languages become a <select>. A <details data-group> set whose panels are each a lone code block folds into one block with a language switcher. Groups holding prose as well stay tab lists.

Long blocks collapse at 500px behind a gradient fade. Click the code to expand, click away to collapse.

Dark mode gets real syntax colours. Shiki now highlights against light-plus and dark-plus, replacing the invert(98%) hue-rotate(180deg) filter.

One copy button module. copy-button.js backs both the code block and the heading copy-URL button, which were carrying ~55 duplicated lines. headers.js drops from 125 lines to 52.

Pages to test

Page What to look for
/docs/kubernetes/steps/kustomize Header with a label, language, copy button
/docs/octopus-rest-api/octopus.client/using-resources Language switcher, .md
/docs/octopus-rest-api/octopus.client/getting-started Language switcher, .mdx, long option label
/docs/octopus-rest-api/octopus.server.exe-command-line/configure Collapsed block, fade, click to expand and away to collapse
/docs/kubernetes/targets/kubernetes-agent/permissions A mixed group that stays tabs
/docs/projects/variables/certificate-variables Rebuilt table (12 rows were losing data)

Try each in dark mode, and hover a heading to check the copy-URL button still behaves.

Worth a look

  • The switcher is a native <select>. The trigger matches the design; the list it opens is the browser's, so that part diverges from the Figma panel. @mandymichael — is that acceptable?
  • ~490 blocks display "Text" because their fence has no language. Accurate, and now visible.
  • Grouped blocks flash: the static HTML has N blocks that JS folds into one. Removing that needs a rehype plugin, which needs markdown.processor untangled first.
  • Code still wraps rather than scrolling horizontally, unchanged from before.

Also in here

Markdownlint runs only on changed files, so touching these surfaced 93 pre-existing violations. Same files on main report the same 93. They are fixed: the certificate-variables table was genuinely broken, six fences had no language, and output-variables used bold text as headings above its fences.

Testing

tests/code-block.spec.ts and tests/copy-button.spec.ts — 12 tests covering the header, the label, copying, switching language, keyboard operation of the select, tabs staying tabs, collapse and expand, and the shell rendering with JavaScript disabled.

Before/after

image image image

@team-marketing-branch-protections

Copy link
Copy Markdown

Pull request environment is available at https://stoctodocspr3317.z22.web.core.windows.net.

You can view the ephemeral environment status in Octopus Deploy.

This environment will be automatically deprovisioned when the pull request is closed, or after 7 days of inactivity.

enf0rc3 and others added 6 commits August 7, 2026 14:10
NES-285. Every fenced block is now wrapped in a shell carrying the block's
label, its language and a copy button, matching the CodeBlock component in the
design system.

A group of <details data-group> panels that are each a lone code block becomes
one block whose header offers the languages in a menu, replacing the tab list.
Groups holding prose as well stay tabs. Blocks over 500px collapse behind a
gradient fade until they are clicked.

Text after the language on the opening fence becomes the block's label. Shiki
drops that meta string, so a transformer keeps it on the element. The fences
that only repeated their own language there have it removed, since that would
render a label saying what the header already shows.

Shiki now highlights against both themes, so dark mode gets real syntax colours
in place of the inverting filter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The workflow lints only the files a branch changed, so touching these surfaced
93 violations that were already there. None came from this branch: the same
files on main report the same errors.

Most were mechanical and went through markdownlint --fix. The rest needed a
decision:

- The expanded-properties table in certificate-variables was missing its
  trailing pipes and its third column, so twelve rows were losing data. It is
  rebuilt with every row filled in, and `header\footer` reads header/footer.
- Six fences had no language. They are `text` now, with a label saying what the
  service message does.
- output-variables used **PowerShell**, **C#**, **Bash**, **F#** and **Python3**
  as headings above their fences. The section heading already names the
  language and the block header now shows it, so the emphasis is gone and each
  fence carries a label instead.
- The two certificate screenshots have alt text.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The frame, header, label and language were being built by JavaScript after the
page loaded. With scripting off, and in the window before hydration, a code
block was bare text on the page background: the border, radius and padding used
to sit on <pre> and now sit on the wrapper that script created.

A Shiki transformer emits the whole shell instead, including the copy button.
The copy handler is delegated at the document level, so it finds a statically
rendered button by the same selector.

Shiki, and not rehype, because plugins registered through `markdown.processor`
never reach .mdx pages. rehypeWbr adds 18 <wbr> elements to the kubernetes-agent
permissions page and none to kustomize.mdx, which has eight matches for it.

code-blocks.js drops from 431 lines to 327: the wrapping, the copy button
markup and the language table all go. It keeps copying, collapsing, and folding
a <details data-group> set into one block with a language menu, which merges
sibling blocks and so cannot be done per-block at build time.

Two tests cover the shell with scripting disabled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both had their own copy of the same fifty-five lines: the revert timer, the
tooltip swap, the live region, and the delegated click. The only thing that
differed was the string each one copies.

copy-button.js takes a selector and a function that reads the text, so a caller
is left with just that function. A button's own data-tooltip is its resting
label, which keeps "Copy URL" on the heading and "Copy to clipboard" on the
code block, and the two share one live region instead of one each.

headers.js goes from 125 lines to 52, code-blocks.js from 327 to 256.

copy-markdown.js stays as it is. It fetches the page over the network before
writing, so it needs the execCommand fallback and cannot read its text
synchronously, which is what keeps the clipboard write inside Safari's user
activation.

The heading button had no test. It has three now, covering both callers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The menu was a <details> with a hand-built option list, and eighteen of its
lines re-implemented Escape-to-close and click-away-to-close. A <select> comes
with those, plus keyboard navigation, focus handling and the mobile picker.

code-blocks.js drops from 256 lines to 201, and the CSS loses the popup panel
along with it.

The trigger still matches the design. The list it opens is the browser's, so
that part no longer matches the Figma panel: worth Mandy's eye before this
merges.

The caret is a mask on a wrapper span, since a <select> renders no
pseudo-element of its own and a background image cannot follow the theme.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The switcher had its own caret-down.svg. The Button component and the copy
markdown menu both draw theirs from the FontAwesome glyph, so this does too and
the asset goes.

Rebasing also turned up a conflict git could not see. Main now sets
`margin-block` on `.page-content :is(pre, figure)`, and every <pre> sits inside
a code block, so the code was pushed away from its own header. The rule points
at .code-block instead, which is the element that wanted the spacing, and the
block drops the 1rem it was setting for itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@enf0rc3
enf0rc3 force-pushed the wl/nes-285-code-block branch from 38710da to 1ab7325 Compare August 7, 2026 02:20
A <details data-group> set with one member built a select holding one option,
which is a control that cannot do anything. There is one such group in the
docs. The block still loses its <details>, and its summary text becomes the
fixed language.

The transformer assigned over root.children, which would drop anything a later
Shiki version put alongside the <pre>. It replaces the <pre> in place instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@enf0rc3
enf0rc3 marked this pull request as ready for review August 7, 2026 02:53
@borland

borland commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

A visual bug I noticed on the staging site. (Note: I haven't looked at the code yet)

Load https://stoctodocspr3317.z22.web.core.windows.net/docs/octopus-rest-api/octopus.client/using-resources

The first code snippet appears, and "PowerShell" is the language.
Flip it to C# and the "C#" part isn't left-aligned. It's not centered either, I'm not sure what's going on with it

image

It also feels like there's a slight delay after changing the select box before the page content updates.
I'm not sure if this is just how browsers deal with select box changes... If it is, then so be it, but if it represents a problem with our code, we should fix it

@borland borland left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking at the code here, claude obviously wrote it all. At a high level it seems fine (no malware or bitcoin miners) but I'm not in position to nitpick the detail... and I don't think we need to either. I'd be happy to approve once you've taken a look at my other comment about text alignment and speed

enf0rc3 and others added 3 commits August 7, 2026 16:18
.btn centres its text, and the caret needs the padding to be lopsided: 5px one
side, 24px the other. A short language centred in what is left of the box
landed about ten pixels off the button's own centre, reading as neither
centred nor aligned.

The box stays as wide as the longest language, so switching causes no reflow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The old module made every <pre> focusable so a long line could be scrolled
without a mouse. Nothing scrolls: `white-space: break-spaces` wraps the code,
and none of the 43 blocks sampled across four pages overflow their box.

The attribute still earns its place on a collapsed block, where the code is the
only thing in the body that can take focus and focus is what opens it. So it
goes on there and nowhere else, which is around 1,500 fewer tab stops.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The fade was 59px and did not start until 60% down it, so it only really acted
over the last 24px and the code read as simply ending. It is 7.5rem now and
fades from its own top edge.

Under it sits a Show more button that becomes Show less. That is what carries
the message, and it is also the keyboard route in, so the <pre> no longer needs
a tab stop and the focus handler that stood in for one is gone.

Opening is one way apart from that button. Collapsing when a click landed
elsewhere pulled the page up by the height of the block, which moved everything
under the reader and lost their place. Collapsing from the button scrolls the
block back into view for the same reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants